import {
  type BaseEvent,
  Box3,
  type Camera,
  EventDispatcher,
  Matrix3,
  type Object3D,
  Sphere,
  Spherical,
  Vector2,
  Vector3,
} from 'three/webgpu';

const STATE = { NONE: -1, ROTATE: 0, ZOOM: 1, PAN: 2 };

const $box = new Box3();
const $sphere = new Sphere();
const $vector = new Vector3();
const $spherical = new Spherical();

type BaseEvents = {
  change: { type: 'change' };
};

const changeEvent: BaseEvent<'change'> = { type: 'change' };

class EditorControls extends EventDispatcher<BaseEvents> {
  public enabled: boolean;
  public center: Vector3;
  public panSpeed: number;
  public zoomSpeed: number;
  public rotationSpeed: number;

  private delta = new Vector3();

  private state = STATE.NONE;

  private normalMatrix = new Matrix3();
  private pointer = new Vector2();
  private pointerOld = new Vector2();

  private pointers: number[] = [];
  private pointerPositions = new Map<number, Vector2>();

  private domElement: HTMLElement | null = null;

  private touches = [new Vector3(), new Vector3(), new Vector3()];
  private prevTouches = [new Vector3(), new Vector3(), new Vector3()];

  private prevDistance: number = 0;

  constructor(public object: Camera) {
    super();

    // API

    this.enabled = true;
    this.center = new Vector3();
    this.panSpeed = 0.002;
    this.zoomSpeed = 0.1;
    this.rotationSpeed = 0.005;
  }

  // events

  public focus = (target: Object3D) => {
    let distance: number;

    $box.setFromObject(target);

    if ($box.isEmpty() === false) {
      $box.getCenter(this.center);
      distance = $box.getBoundingSphere($sphere).radius;
    } else {
      // Focusing on an Group, AmbientLight, etc

      this.center.setFromMatrixPosition(target.matrixWorld);
      distance = 0.1;
    }

    this.delta.set(0, 0, 1);
    this.delta.applyQuaternion(this.object.quaternion);
    this.delta.multiplyScalar(distance * 4);

    this.object.position.copy(this.center).add(this.delta);

    this.dispatchEvent(changeEvent);
  };

  public pan = (delta: Vector3) => {
    const distance = this.object.position.distanceTo(this.center);

    delta.multiplyScalar(distance * this.panSpeed);
    delta.applyMatrix3(this.normalMatrix.getNormalMatrix(this.object.matrix));

    this.object.position.add(delta);
    this.center.add(delta);

    this.dispatchEvent(changeEvent);
  };

  public zoom = (delta: Vector3) => {
    const distance = this.object.position.distanceTo(this.center);

    delta.multiplyScalar(distance * this.zoomSpeed);

    if (delta.length() > distance) return;

    delta.applyMatrix3(this.normalMatrix.getNormalMatrix(this.object.matrix));

    this.object.position.add(delta);

    this.dispatchEvent(changeEvent);
  };

  public rotate = (delta: Vector3) => {
    const { object, rotationSpeed, center } = this;

    $vector.copy(object.position).sub(center);

    $spherical.setFromVector3($vector);

    $spherical.theta += delta.x * rotationSpeed;
    $spherical.phi += delta.y * rotationSpeed;

    $spherical.makeSafe();

    $vector.setFromSpherical($spherical);

    object.position.copy(center).add($vector);

    object.lookAt(center);

    this.dispatchEvent(changeEvent);
  };

  public connect = (element: HTMLElement) => {
    if (this.domElement !== null) this.disconnect();

    this.domElement = element;

    element.addEventListener('contextmenu', this.contextmenu);
    element.addEventListener('dblclick', this.onMouseUp);
    element.addEventListener('wheel', this.onMouseWheel, { passive: false });

    element.addEventListener('pointerdown', this.onPointerDown);
  };

  public disconnect = () => {
    if (this.domElement) {
      this.domElement.removeEventListener('contextmenu', this.contextmenu);
      this.domElement.removeEventListener('dblclick', this.onMouseUp);
      this.domElement.removeEventListener('wheel', this.onMouseWheel);

      this.domElement.removeEventListener('pointerdown', this.onPointerDown);

      this.domElement = null;
    }
  };

  //

  private onPointerDown = (event: PointerEvent) => {
    if (this.enabled === false) return;

    const targetElement = this.domElement as HTMLElement;

    if (this.pointers.length === 0) {
      targetElement.setPointerCapture(event.pointerId);
      targetElement.ownerDocument.addEventListener('pointermove', this.onPointerMove);
      targetElement.ownerDocument.addEventListener('pointerup', this.onPointerUp);
    }

    //

    if (this.isTrackingPointer(event)) return;

    //

    this.addPointer(event);

    if (event.pointerType === 'touch') {
      this.onTouchStart(event);
    } else {
      this.onMouseDown(event);
    }
  };

  private onPointerMove = (event: PointerEvent) => {
    if (this.enabled === false) return;

    if (event.pointerType === 'touch') {
      this.onTouchMove(event);
    } else {
      this.onMouseMove(event);
    }
  };

  private onPointerUp = (event: PointerEvent) => {
    this.removePointer(event);
    const pointers = this.pointers;
    const targetElement = this.domElement as HTMLElement;
    switch (pointers.length) {
      case 0:
        {
          targetElement.releasePointerCapture(event.pointerId);

          targetElement.ownerDocument.removeEventListener('pointermove', this.onPointerMove);
          targetElement.ownerDocument.removeEventListener('pointerup', this.onPointerUp);
        }

        break;

      case 1:
        {
          const pointerId = pointers[0];
          const position = this.pointerPositions.get(pointerId) as Vector2;

          // minimal placeholder event - allows state correction on pointer-up
          this.onTouchStart({ pointerId: pointerId, pageX: position.x, pageY: position.y } as PointerEvent);
        }

        break;
    }
  };

  // mouse

  private onMouseDown = (event: MouseEvent) => {
    if (event.button === 0) {
      this.state = STATE.ROTATE;
    } else if (event.button === 1) {
      this.state = STATE.ZOOM;
    } else if (event.button === 2) {
      this.state = STATE.PAN;
    }

    this.pointerOld.set(event.clientX, event.clientY);
  };

  private onMouseMove = (event: MouseEvent) => {
    const { pointerOld, delta } = this;

    const pointer = this.pointer;

    const state = this.state;

    pointer.set(event.clientX, event.clientY);

    const movementX = pointer.x - pointerOld.x;
    const movementY = pointer.y - pointerOld.y;

    if (state === STATE.ROTATE) {
      this.rotate(delta.set(-movementX, -movementY, 0));
    } else if (state === STATE.ZOOM) {
      this.zoom(delta.set(0, 0, movementY));
    } else if (state === STATE.PAN) {
      this.pan(delta.set(-movementX, movementY, 0));
    }

    pointerOld.set(event.clientX, event.clientY);
  };

  private onMouseUp = () => {
    this.state = STATE.NONE;
  };

  private onMouseWheel = (event: WheelEvent) => {
    if (this.enabled === false) return;

    event.preventDefault();

    // Normalize deltaY due to https://bugzilla.mozilla.org/show_bug.cgi?id=1392460
    this.zoom(this.delta.set(0, 0, event.deltaY > 0 ? 1 : -1));
  };

  private contextmenu = (event: MouseEvent) => {
    event.preventDefault();
  };

  private onTouchStart = (event: PointerEvent) => {
    this.trackPointer(event);

    const pointers = this.pointers;
    const touches = this.touches;
    const prevTouches = this.prevTouches;

    switch (pointers.length) {
      case 1:
        {
          touches[0].set(event.pageX, event.pageY, 0).divideScalar(window.devicePixelRatio);
          touches[1].set(event.pageX, event.pageY, 0).divideScalar(window.devicePixelRatio);
        }

        break;

      case 2:
        {
          const position = this.getSecondPointerPosition(event) as Vector2;

          touches[0].set(event.pageX, event.pageY, 0).divideScalar(window.devicePixelRatio);
          touches[1].set(position.x, position.y, 0).divideScalar(window.devicePixelRatio);
          this.prevDistance = touches[0].distanceTo(touches[1]);
        }

        break;
    }

    prevTouches[0].copy(touches[0]);
    prevTouches[1].copy(touches[1]);
  };

  private onTouchMove = (event: PointerEvent) => {
    this.trackPointer(event);

    function getClosest(_touch: Vector3, _touches: Vector3[]) {
      let closest = _touches[0];

      for (const touch2 of _touches) {
        if (closest.distanceTo(_touch) > touch2.distanceTo(_touch)) closest = touch2;
      }

      return closest;
    }

    const pointers = this.pointers;
    const touches = this.touches;
    const prevTouches = this.prevTouches;

    switch (pointers.length) {
      case 1:
        {
          touches[0].set(event.pageX, event.pageY, 0).divideScalar(window.devicePixelRatio);
          touches[1].set(event.pageX, event.pageY, 0).divideScalar(window.devicePixelRatio);
          this.rotate(touches[0].sub(getClosest(touches[0], prevTouches)).multiplyScalar(-1));
        }

        break;

      case 2:
        {
          const position = this.getSecondPointerPosition(event) as Vector2;

          touches[0].set(event.pageX, event.pageY, 0).divideScalar(window.devicePixelRatio);
          touches[1].set(position.x, position.y, 0).divideScalar(window.devicePixelRatio);
          const distance = touches[0].distanceTo(touches[1]);
          this.zoom(this.delta.set(0, 0, this.prevDistance - distance));
          this.prevDistance = distance;

          const offset0 = touches[0].clone().sub(getClosest(touches[0], prevTouches));
          const offset1 = touches[1].clone().sub(getClosest(touches[1], prevTouches));
          offset0.x = -offset0.x;
          offset1.x = -offset1.x;

          this.pan(offset0.add(offset1));
        }
        break;
    }

    prevTouches[0].copy(touches[0]);
    prevTouches[1].copy(touches[1]);
  };

  private addPointer(event: PointerEvent) {
    this.pointers.push(event.pointerId);
  }

  private removePointer = (event: PointerEvent) => {
    this.pointerPositions.delete(event.pointerId);
    const pointers = this.pointers;
    for (let i = 0; i < pointers.length; i++) {
      if (pointers[i] === event.pointerId) {
        pointers.splice(i, 1);
        return;
      }
    }
  };

  private isTrackingPointer = (event: PointerEvent) => {
    const pointers = this.pointers;
    for (let i = 0; i < pointers.length; i++) {
      if (pointers[i] === event.pointerId) return true;
    }

    return false;
  };

  private trackPointer = (event: PointerEvent) => {
    let position = this.pointerPositions.get(event.pointerId);

    if (position === undefined) {
      position = new Vector2();
      this.pointerPositions.set(event.pointerId, position);
    }

    position.set(event.pageX, event.pageY);
  };

  private getSecondPointerPosition = (event: PointerEvent) => {
    const pointers = this.pointers;
    const pointerId = event.pointerId === pointers[0] ? pointers[1] : pointers[0];

    return this.pointerPositions.get(pointerId);
  };
}

export { EditorControls };
